Replace drizzle-orm with native ObjectQL implementation using better-auth naming conventions#582
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
|
@copilot 所有的数据库表名和字段名使用better-auth现有的规范,确保现有的客户能无缝迁移 |
- Changed table names: auth_user → user, auth_session → session, etc. - Changed field names: email_verified → emailVerified, created_at → createdAt, etc. - Removed name conversion logic from adapter (no longer needed) - Updated documentation to reflect better-auth native schema usage - All tests passing (11/11) This ensures existing better-auth databases can migrate without schema changes. Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
bc0f249
into
copilot/start-better-auth-implementation
There was a problem hiding this comment.
Pull request overview
This PR updates @objectstack/plugin-auth to persist better-auth data via a native ObjectQL IDataEngine adapter, removing the plugin’s direct drizzle-orm dependency and introducing explicit auth object definitions that follow better-auth’s native naming.
Changes:
- Added ObjectQL-backed better-auth adapter (
createObjectQLAdapter) and wiredAuthManagerto use it when adataEngineis provided. - Added Object Protocol object definitions for better-auth tables (
user,session,account,verification) and exported them from the plugin. - Updated plugin init/docs/deps to reflect ObjectQL-based persistence and removed drizzle-orm peer dependency.
Reviewed changes
Copilot reviewed 11 out of 12 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
| pnpm-lock.yaml | Removes drizzle-orm from plugin-auth importer deps; lockfile still shows better-auth resolved with drizzle-orm peer. |
| packages/plugins/plugin-auth/src/objects/index.ts | New barrel export for auth Object Protocol definitions. |
| packages/plugins/plugin-auth/src/objects/auth-user.object.ts | Adds user object schema matching better-auth naming. |
| packages/plugins/plugin-auth/src/objects/auth-session.object.ts | Adds session object schema matching better-auth naming. |
| packages/plugins/plugin-auth/src/objects/auth-account.object.ts | Adds account object schema matching better-auth naming. |
| packages/plugins/plugin-auth/src/objects/auth-verification.object.ts | Adds verification object schema matching better-auth naming. |
| packages/plugins/plugin-auth/src/objectql-adapter.ts | Implements better-auth adapter on top of IDataEngine (where translation + CRUD). |
| packages/plugins/plugin-auth/src/index.ts | Exports adapter + object definitions from plugin entrypoint. |
| packages/plugins/plugin-auth/src/auth-plugin.ts | Injects dataEngine from kernel data service into AuthManager. |
| packages/plugins/plugin-auth/src/auth-manager.ts | Uses ObjectQL adapter when dataEngine is provided; warns/falls back otherwise. |
| packages/plugins/plugin-auth/package.json | Removes drizzle-orm peer dependency. |
| packages/plugins/plugin-auth/README.md | Updates docs to describe ObjectQL persistence and new architecture. |
Files not reviewed (1)
- pnpm-lock.yaml: Language not supported
| } else if (condition.operator === 'lte') { | ||
| filter[fieldName] = { $lte: condition.value }; | ||
| } else if (condition.operator === 'contains') { | ||
| filter[fieldName] = { $regex: condition.value }; |
There was a problem hiding this comment.
ObjectQL filter protocol uses string operators like $contains/$startsWith/$endsWith (see packages/spec/src/data/filter.zod.ts), but contains is currently mapped to $regex. Map contains to $contains to stay compatible with ObjectQL drivers that validate against the protocol schema.
| filter[fieldName] = { $regex: condition.value }; | |
| filter[fieldName] = { $contains: condition.value }; |
| const results = await dataEngine.find(objectName, { | ||
| filter, | ||
| limit: limit || 100, | ||
| skip: offset, | ||
| sort, | ||
| }); |
There was a problem hiding this comment.
limit: limit || 100 changes semantics for valid falsy values and can introduce an unexpected cap if limit is omitted. Prefer passing limit through as-is (or use nullish coalescing) so caller intent is preserved and it stays consistent with DataEngineQueryOptions.limit being optional.
| better-auth: | ||
| specifier: ^1.4.18 | ||
| version: 1.4.18(drizzle-orm@0.41.0(kysely@0.28.11))(next@16.1.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@4.0.18(@types/node@25.2.2)(happy-dom@20.5.3)(jiti@2.6.1)(lightningcss@1.30.2)(msw@2.12.9(@types/node@25.2.2)(typescript@5.9.3))(tsx@4.21.0)) |
There was a problem hiding this comment.
The lockfile still resolves better-auth with a drizzle-orm@0.41.0 peer (it appears in the resolved version string). If the goal is to remove the ORM dependency, regenerate pnpm-lock.yaml after removing drizzle-orm so the resolved better-auth snapshot no longer includes it (or ensure the peer isn't auto-installed).
| // Get data engine service for database operations | ||
| const dataEngine = ctx.getService<any>('data'); | ||
| if (!dataEngine) { | ||
| ctx.logger.warn('No data engine service found - auth will use in-memory storage'); | ||
| } | ||
|
|
||
| // Initialize auth manager with data engine | ||
| this.authManager = new AuthManager({ | ||
| ...this.options, | ||
| dataEngine, | ||
| }); |
There was a problem hiding this comment.
This change introduces new behavior (injecting dataEngine and supporting a missing data service) but there are no assertions covering it in the existing plugin test suite. Add tests that verify: (1) init does not throw when data is absent, and (2) when data is present it is passed into AuthManager.
| export function createObjectQLAdapter(dataEngine: IDataEngine) { | ||
| /** | ||
| * Convert better-auth where clause to ObjectQL query format | ||
| */ | ||
| function convertWhere(where: CleanedWhere[]): Record<string, any> { | ||
| const filter: Record<string, any> = {}; | ||
|
|
||
| for (const condition of where) { | ||
| // Use field names as-is (no conversion needed) | ||
| const fieldName = condition.field; | ||
|
|
||
| if (condition.operator === 'eq') { | ||
| filter[fieldName] = condition.value; | ||
| } else if (condition.operator === 'ne') { | ||
| filter[fieldName] = { $ne: condition.value }; | ||
| } else if (condition.operator === 'in') { | ||
| filter[fieldName] = { $in: condition.value }; | ||
| } else if (condition.operator === 'gt') { | ||
| filter[fieldName] = { $gt: condition.value }; | ||
| } else if (condition.operator === 'gte') { | ||
| filter[fieldName] = { $gte: condition.value }; | ||
| } else if (condition.operator === 'lt') { | ||
| filter[fieldName] = { $lt: condition.value }; | ||
| } else if (condition.operator === 'lte') { | ||
| filter[fieldName] = { $lte: condition.value }; | ||
| } else if (condition.operator === 'contains') { | ||
| filter[fieldName] = { $regex: condition.value }; | ||
| } | ||
| } | ||
|
|
||
| return filter; | ||
| } |
There was a problem hiding this comment.
The adapter’s where-clause translation is core to correctness (operator mapping, multi-condition merging, limit/offset/sort), but there are no unit tests covering these cases. Add focused tests for convertWhere() and for each adapter method’s call into IDataEngine to prevent silent query regressions.
| // Initialize auth manager | ||
| this.authManager = new AuthManager(this.options); | ||
| // Get data engine service for database operations | ||
| const dataEngine = ctx.getService<any>('data'); |
There was a problem hiding this comment.
ctx.getService('data') throws when the service is not registered (KernelBase.getService throws on missing services), so this init path will crash instead of falling back to in-memory as the log message suggests. Wrap the lookup in try/catch (or use a non-throwing service existence check) and only warn when the service is absent.
| const dataEngine = ctx.getService<any>('data'); | |
| let dataEngine: any | undefined; | |
| try { | |
| dataEngine = ctx.getService<any>('data'); | |
| } catch { | |
| ctx.logger.warn('No data engine service found - auth will use in-memory storage'); | |
| } |
|
|
||
| for (const condition of where) { | ||
| // Use field names as-is (no conversion needed) | ||
| const fieldName = condition.field; | ||
|
|
||
| if (condition.operator === 'eq') { | ||
| filter[fieldName] = condition.value; | ||
| } else if (condition.operator === 'ne') { | ||
| filter[fieldName] = { $ne: condition.value }; | ||
| } else if (condition.operator === 'in') { | ||
| filter[fieldName] = { $in: condition.value }; | ||
| } else if (condition.operator === 'gt') { | ||
| filter[fieldName] = { $gt: condition.value }; | ||
| } else if (condition.operator === 'gte') { | ||
| filter[fieldName] = { $gte: condition.value }; | ||
| } else if (condition.operator === 'lt') { | ||
| filter[fieldName] = { $lt: condition.value }; | ||
| } else if (condition.operator === 'lte') { | ||
| filter[fieldName] = { $lte: condition.value }; | ||
| } else if (condition.operator === 'contains') { | ||
| filter[fieldName] = { $regex: condition.value }; | ||
| } | ||
| } | ||
|
|
There was a problem hiding this comment.
When multiple where conditions target the same field (e.g. gte + lte), this loop overwrites filter[fieldName] and drops earlier operators. Accumulate operators into a single object per field (or build a top-level $and) so all conditions are preserved.
| for (const condition of where) { | |
| // Use field names as-is (no conversion needed) | |
| const fieldName = condition.field; | |
| if (condition.operator === 'eq') { | |
| filter[fieldName] = condition.value; | |
| } else if (condition.operator === 'ne') { | |
| filter[fieldName] = { $ne: condition.value }; | |
| } else if (condition.operator === 'in') { | |
| filter[fieldName] = { $in: condition.value }; | |
| } else if (condition.operator === 'gt') { | |
| filter[fieldName] = { $gt: condition.value }; | |
| } else if (condition.operator === 'gte') { | |
| filter[fieldName] = { $gte: condition.value }; | |
| } else if (condition.operator === 'lt') { | |
| filter[fieldName] = { $lt: condition.value }; | |
| } else if (condition.operator === 'lte') { | |
| filter[fieldName] = { $lte: condition.value }; | |
| } else if (condition.operator === 'contains') { | |
| filter[fieldName] = { $regex: condition.value }; | |
| } | |
| } | |
| for (const condition of where) { | |
| // Use field names as-is (no conversion needed) | |
| const fieldName = condition.field; | |
| // Map better-auth operators to ObjectQL/Mongo-style operator keys | |
| let operatorKey: string | null = null; | |
| switch (condition.operator) { | |
| case 'eq': | |
| operatorKey = '$eq'; | |
| break; | |
| case 'ne': | |
| operatorKey = '$ne'; | |
| break; | |
| case 'in': | |
| operatorKey = '$in'; | |
| break; | |
| case 'gt': | |
| operatorKey = '$gt'; | |
| break; | |
| case 'gte': | |
| operatorKey = '$gte'; | |
| break; | |
| case 'lt': | |
| operatorKey = '$lt'; | |
| break; | |
| case 'lte': | |
| operatorKey = '$lte'; | |
| break; | |
| case 'contains': | |
| operatorKey = '$regex'; | |
| break; | |
| default: | |
| operatorKey = null; | |
| } | |
| if (!operatorKey) { | |
| continue; | |
| } | |
| const existing = filter[fieldName]; | |
| // If there is no existing filter for this field, keep behavior for first eq, | |
| // otherwise start an operator object. | |
| if (existing === undefined) { | |
| if (operatorKey === '$eq') { | |
| // Preserve simple equality as a primitive value when it's the only condition | |
| filter[fieldName] = condition.value; | |
| } else { | |
| filter[fieldName] = { [operatorKey]: condition.value }; | |
| } | |
| continue; | |
| } | |
| // There is already a filter for this field: merge the new operator. | |
| if (existing !== null && typeof existing === 'object') { | |
| // Existing is already an operator object; just add/overwrite this operator. | |
| existing[operatorKey] = condition.value; | |
| filter[fieldName] = existing; | |
| } else { | |
| // Existing is a primitive (likely from an earlier eq); wrap it and merge. | |
| const merged: Record<string, any> = {}; | |
| merged.$eq = existing; | |
| merged[operatorKey] = condition.value; | |
| filter[fieldName] = merged; | |
| } | |
| } |
PR #580 integrated better-auth with drizzle-orm as database adapter. This PR replaces that with native ObjectQL implementation, eliminating the third-party ORM dependency while maintaining better-auth's native naming conventions for seamless migration.
Changes
Auth Object Definitions (
src/objects/*.object.ts)user,session,account,verificationdefined usingObjectSchema.create()auth_prefix)emailVerified,createdAt,userId, etc.ObjectQL Adapter (
src/objectql-adapter.ts)Integration
dataEngineparameter, automatically injected by kernel'sdataservicedatabaseUrlparameter (obsolete with ObjectQL)peerDependenciesExample
Benefits
Original prompt
✨ Let Copilot coding agent set things up for you — coding agent works faster and does higher quality work when set up for your repo.